Skip to content
标签
缓存
字数
2550 字
阅读时间
11 分钟

一、基本使用

1.1 概述

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库.存在下面的问题:

•请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈

•Redis缓存失效时,会对数据库产生冲击

多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存
  • 访问非静态资源(ajax查询数据)时,访问服务端
  • 请求到达Nginx后,优先读取Nginx本地缓存
  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
  • 如果Redis查询未命中,则查询Tomcat
  • 请求进入Tomcat后,优先查询JVM进程缓存
  • 如果JVM进程缓存未命中,则查询数据库

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器

1.2 基础知识

1.2.1 jvm进程缓存

缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:

  • 分布式缓存,例如Redis:
    • 优点:存储容量更大、可靠性更好、可以在集群间共享
    • 缺点:访问缓存有网络开销
    • 场景:缓存数据量较大、可靠性要求较高、需要在集群间共享
  • 进程本地缓存,例如HashMap、GuavaCache:
    • 优点:读取本地内存,没有网络开销,速度更快
    • 缺点:存储容量有限、可靠性较低、无法共享
    • 场景:性能要求较高,缓存数据量较小

Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。GitHub地址:https://github.com/ben-manes/caffeine

  • 使用

    java
    @Test
    void testBasicOps() {
        // 构建cache对象
        // 在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
        Cache<String, String> cache = Caffeine.newBuilder().build()
            .maximumSize(1000) // 设置缓存大小上限为 1
            .initialCapacity(100)// 初始大小
            .expireAfterWrite(Duration.ofSeconds(10))// 设置缓存有效期为 10 秒,从最后一次写入开始计时 
            ;
    
        // 存数据
        cache.put("gf", "迪丽热巴");
    
        // 取数据
        String gf = cache.getIfPresent("gf");
        System.out.println("gf = " + gf);
    
        // 取数据,包含两个参数:
        // 参数一:缓存的key
        // 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
        // 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
        String defaultGF = cache.get("defaultGF", key -> {
            // 根据key去数据库查询数据
            return "柳岩";
        });
        System.out.println("defaultGF = " + defaultGF);
    }

1.2.2 jvm进程缓存应用实现

通过OpenResty监听路径,实现通过lua脚本文件处理逻辑。逻辑中调用负载均衡的tomcat请求获取tomcat中的jvm缓存数据。(实现数据拼接)后响应给客户端。

负载均衡的tomcat还需配置基于路径的负载均衡,根据路径的hash值运算并取余,访问特定的tomcat。解决某一商品多次访问可能请求不同tomcat的问题。

1.2.3 Redis缓存预热

冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。

缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到Redis中。

一般在项目启动时,将缓存数据刷入到redis中,通过openResty查询redis中数据。在页面调用服务器查询数据时,通过OpenResty查询redis中数据,没有查到时,查询tomcat中的数据。

1.2.4 nginx本地缓存

OpenResty为Nginx提供了shard dict的功能,可以在nginx的多个worker之间共享数据,实现缓存功能。

1)开启共享字典,在nginx.conf的http下添加配置:

nginx
 # 共享字典,也就是本地缓存,名称叫做:item_cache,大小150m
 lua_shared_dict item_cache 150m;

2)操作共享字典:

lua
-- 获取本地缓存对象
local item_cache = ngx.shared.item_cache
-- 存储, 指定key、value、过期时间,单位s,默认为0代表永不过期
item_cache:set('key', 'value', 1000)
-- 读取
local val = item_cache:get('key')

1.2.6 缓存同步

多数情况下,浏览器查询到的都是缓存数据,如果缓存数据与数据库数据存在较大差异,可能会产生比较严重的后果。

所以我们必须保证数据库数据、缓存数据的一致性,这就是缓存与数据库的同步。

缓存策略

缓存数据同步的常见方式有三种:

设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新

  • 优势:简单、方便
  • 缺点:时效性差,缓存过期之前可能不一致
  • 场景:更新频率较低,时效性要求低的业务

同步双写:在修改数据库的同时,直接修改缓存

  • 优势:时效性强,缓存与数据库强一致
  • 缺点:有代码侵入,耦合度高;
  • 场景:对一致性、时效性要求较高的缓存数据

**异步通知:**修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据

  • 优势:低耦合,可以同时通知多个缓存服务
  • 缺点:时效性一般,可能存在中间不一致状态
  • 场景:时效性要求一般,有多个服务需要同步

异步实现

  • 基于MQ方式

    当数据被修改后,发送消息到mq中,缓存服务监听消息,完成对缓存的更新。存在少量的代码入侵

  • 基于Canal的通知

    当数据被修改后,业务就直接结束。由Canal监听Mysql数据库变化,当发生变化后,通知缓存服务。缓存服务接收到通知,对缓存进行更新。

    Canal与springboot整合后,可以通过实现接口和注解监听特定的表。当有操作时对数据进行操作。

1.2.7 代码逻辑

  • 构建jvm缓存

    java
    
    import com.github.benmanes.caffeine.cache.Cache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import com.heima.item.pojo.Item;
    import com.heima.item.pojo.ItemStock;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class CaffeineConfig {
    
        @Bean
        public Cache<Long, Item> itemCache(){
            return Caffeine.newBuilder()
                    .initialCapacity(100)
                    .maximumSize(10_000)
                    .build();
        }
    
        @Bean
        public Cache<Long, ItemStock> stockCache(){
            return Caffeine.newBuilder()
                    .initialCapacity(100)
                    .maximumSize(10_000)
                    .build();
        }
    }
  • OpenResty监听路径,通过lua脚本处理配置

    nginx
    location  /api/item {
        # 默认的响应类型
        default_type application/json;
        # 响应结果由lua/item.lua文件来决定
        content_by_lua_file lua/item.lua;
    }
  • 基于请求路径hash负载均衡配置

    nginx
    upstream tomcat-cluster {
        hash $request_uri;
        server 192.168.150.1:8081;
        server 192.168.150.1:8082;
    }
  • 缓存预热及操作缓存

    java
    
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.heima.item.pojo.Item;
    import com.heima.item.pojo.ItemStock;
    import com.heima.item.service.IItemService;
    import com.heima.item.service.IItemStockService;
    import org.springframework.beans.factory.InitializingBean;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.util.List;
    
    @Component
    public class RedisHandler implements InitializingBean {
    
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Autowired
        private IItemService itemService;
        @Autowired
        private IItemStockService stockService;
    
        private static final ObjectMapper MAPPER = new ObjectMapper();
    
        @Override
        public void afterPropertiesSet() throws Exception {
            // 初始化缓存
            // 1.查询商品信息
            List<Item> itemList = itemService.list();
            // 2.放入缓存
            for (Item item : itemList) {
                // 2.1.item序列化为JSON
                String json = MAPPER.writeValueAsString(item);
                // 2.2.存入redis
                redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
            }
    
            // 3.查询商品库存信息
            List<ItemStock> stockList = stockService.list();
            // 4.放入缓存
            for (ItemStock stock : stockList) {
                // 2.1.item序列化为JSON
                String json = MAPPER.writeValueAsString(stock);
                // 2.2.存入redis
                redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
            }
        }
    
        public void saveItem(Item item) {
            try {
                String json = MAPPER.writeValueAsString(item);
                redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }
    
        public void deleteItemById(Long id) {
            redisTemplate.delete("item:id:" + id);
        }
    }
  • lua脚本实现多级缓存

    lua
    -- 导入common函数库
    local common = require('common')
    local read_http = common.read_http
    local read_redis = common.read_redis
    -- 导入cjson库
    local cjson = require('cjson')
    -- 导入共享词典,本地缓存
    local item_cache = ngx.shared.item_cache
    
    -- 封装查询函数
    function read_data(key, expire, path, params)
        -- 查询本地缓存
        local val = item_cache:get(key)
        if not val then
            ngx.log(ngx.ERR, "本地缓存查询失败,尝试查询Redis, key: ", key)
            -- 查询redis
            val = read_redis("127.0.0.1", 6379, key)
            -- 判断查询结果
            if not val then
                ngx.log(ngx.ERR, "redis查询失败,尝试查询http, key: ", key)
                -- redis查询失败,去查询http
                val = read_http(path, params)
            end
        end
        -- 查询成功,把数据写入本地缓存
        item_cache:set(key, val, expire)
        -- 返回数据
        return val
    end
    
    -- 获取路径参数
    local id = ngx.var[1]
    
    -- 查询商品信息
    local itemJSON = read_data("item:id:" .. id, 1800,  "/item/" .. id, nil)
    -- 查询库存信息
    local stockJSON = read_data("item:stock:id:" .. id, 60, "/item/stock/" .. id, nil)
    
    -- JSON转化为lua的table
    local item = cjson.decode(itemJSON)
    local stock = cjson.decode(stockJSON)
    -- 组合数据
    item.stock = stock.stock
    item.sold = stock.sold
    
    -- 把item序列化为json 返回结果
    ngx.say(cjson.encode(item))
  • Canal监听表变化的逻辑

    java
    
    import com.github.benmanes.caffeine.cache.Cache;
    import com.heima.item.config.RedisHandler;
    import com.heima.item.pojo.Item;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    import top.javatool.canal.client.annotation.CanalTable;
    import top.javatool.canal.client.handler.EntryHandler;
    
    @CanalTable("tb_item")
    @Component
    public class ItemHandler implements EntryHandler<Item> {
    
        @Autowired
        private RedisHandler redisHandler;
        @Autowired
        private Cache<Long, Item> itemCache;
    
        @Override
        public void insert(Item item) {
            // 写数据到JVM进程缓存
            itemCache.put(item.getId(), item);
            // 写数据到redis
            redisHandler.saveItem(item);
        }
    
        @Override
        public void update(Item before, Item after) {
            // 写数据到JVM进程缓存
            itemCache.put(after.getId(), after);
            // 写数据到redis
            redisHandler.saveItem(after);
        }
    
        @Override
        public void delete(Item item) {
            // 删除数据到JVM进程缓存
            itemCache.invalidate(item.getId());
            // 删除数据到redis
            redisHandler.deleteItemById(item.getId());
        }
    }